diff --git a/README.md b/README.md index 26699f2ea1..e2aa20556f 100644 --- a/README.md +++ b/README.md @@ -1084,6 +1084,42 @@ Add LSP servers via the `lsp` option in `~/.config/opencode/oh-my-opencode.json` Each server supports: `command`, `extensions`, `priority`, `env`, `initialization`, `disabled`. +### External CLI + +Use external CLI tools (like Cursor) for background agents. This allows you to leverage your existing subscriptions for background agent execution instead of OpenCode's native agent system. + +```json +{ + "external_cli": { + "enabled": true, + "provider": "cursor", + "models": { + "explore": "gpt-5.1-codex-mini", + "librarian": "gpt-5.2" + }, + "default_model": "gpt-5.1-codex", + "timeout": 300000 + } +} +``` + +| Option | Default | Description | +| ------ | ------- | ----------- | +| `enabled` | `false` | Enable external CLI backend for background agents (opt-in) | +| `provider` | `cursor` | CLI provider to use. Currently supported: `cursor` | +| `models` | `{}` | Agent-specific model overrides. Keys are agent names, values are provider-specific model names | +| `default_model` | `gpt-5.1-codex` | Default model when no agent-specific model is configured | +| `workspace` | current directory | Workspace path for CLI commands | +| `timeout` | `300000` | Timeout in milliseconds (5 minutes default, max 10 minutes) | + +#### Cursor Provider + +**Requirements**: Cursor must be installed and authenticated. Test with `cursor-agent -p --model --output-format json "test"`. + +#### Adding New Providers + +The external CLI system is designed to be extensible. New providers can be added by implementing the `ExternalCliProviderInterface` in `src/features/external-cli/providers/`. + ### Experimental Opt-in experimental features that may change or be removed in future versions. Use with caution. diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 804a0df11f..8256048e23 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -1659,6 +1659,44 @@ } } }, + "external_cli": { + "type": "object", + "properties": { + "enabled": { + "default": false, + "type": "boolean" + }, + "provider": { + "default": "cursor", + "type": "string", + "enum": [ + "cursor" + ] + }, + "models": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "default_model": { + "default": "gpt-5.1-codex", + "type": "string" + }, + "workspace": { + "type": "string" + }, + "timeout": { + "default": 300000, + "type": "number", + "minimum": 1000, + "maximum": 600000 + } + } + }, "background_task": { "type": "object", "properties": { diff --git a/src/config/schema.ts b/src/config/schema.ts index 2b09abaad6..bbc0a9f2ce 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -232,6 +232,23 @@ export const RalphLoopConfigSchema = z.object({ state_dir: z.string().optional(), }) +export const ExternalCliProviderSchema = z.enum(["cursor"]) + +export const ExternalCliConfigSchema = z.object({ + /** Enable external CLI as backend for background agents (default: false - opt-in feature) */ + enabled: z.boolean().default(false), + /** CLI provider to use for background agent execution */ + provider: ExternalCliProviderSchema.default("cursor"), + /** Model mapping for each agent type. Keys are agent names (explore, librarian, etc.), values are provider-specific model names */ + models: z.record(z.string(), z.string()).optional(), + /** Default model to use when no agent-specific model is configured */ + default_model: z.string().default("gpt-5.1-codex"), + /** Workspace path for CLI commands (default: current directory) */ + workspace: z.string().optional(), + /** Timeout in milliseconds for CLI commands (default: 300000 = 5 minutes) */ + timeout: z.number().min(1000).max(600000).default(300000), +}) + export const BackgroundTaskConfigSchema = z.object({ defaultConcurrency: z.number().min(1).optional(), providerConcurrency: z.record(z.string(), z.number().min(1)).optional(), @@ -259,6 +276,7 @@ export const OhMyOpenCodeConfigSchema = z.object({ auto_update: z.boolean().optional(), skills: SkillsConfigSchema.optional(), ralph_loop: RalphLoopConfigSchema.optional(), + external_cli: ExternalCliConfigSchema.optional(), background_task: BackgroundTaskConfigSchema.optional(), notification: NotificationConfigSchema.optional(), }) @@ -278,6 +296,8 @@ export type DynamicContextPruningConfig = z.infer export type SkillDefinition = z.infer export type RalphLoopConfig = z.infer +export type ExternalCliProvider = z.infer +export type ExternalCliConfig = z.infer export type NotificationConfig = z.infer export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types" diff --git a/src/features/background-agent/manager.test.ts b/src/features/background-agent/manager.test.ts index 6bd818c966..776a820818 100644 --- a/src/features/background-agent/manager.test.ts +++ b/src/features/background-agent/manager.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect, beforeEach } from "bun:test" import type { BackgroundTask } from "./types" +import type { ExternalCliBackendConfig } from "../external-cli" const TASK_TTL_MS = 30 * 60 * 1000 @@ -115,6 +116,7 @@ function createMockTask(overrides: Partial & { id: string; sessi agent: "test-agent", status: "running", startedAt: new Date(), + backend: "opencode", ...overrides, } } @@ -482,3 +484,60 @@ describe("BackgroundManager.pruneStaleTasksAndNotifications", () => { expect(manager.getTask("task-fresh")).toBeDefined() }) }) + +describe("BackgroundManager external-cli configuration", () => { + test("should identify external-cli tasks by backend field", () => { + // #given + const externalTask = createMockTask({ + id: "external-task-1", + sessionID: "cursor_external-task-1", + parentSessionID: "main-session", + backend: "external-cli", + }) + const opencodeTask = createMockTask({ + id: "opencode-task-1", + sessionID: "opencode-session-1", + parentSessionID: "main-session", + backend: "opencode", + }) + + // #then + expect(externalTask.backend).toBe("external-cli") + expect(opencodeTask.backend).toBe("opencode") + }) + + test("external-cli config should have all required fields including provider", () => { + // #given + const config: ExternalCliBackendConfig = { + enabled: true, + provider: "cursor", + models: { explore: "gpt-5.1-codex-mini", librarian: "gpt-5.2" }, + default_model: "gpt-5.1-codex", + workspace: "/path/to/project", + timeout: 300000, + } + + // #then + expect(config.enabled).toBe(true) + expect(config.provider).toBe("cursor") + expect(config.models?.explore).toBe("gpt-5.1-codex-mini") + expect(config.default_model).toBe("gpt-5.1-codex") + expect(config.timeout).toBe(300000) + }) + + test("external-cli config should work with minimal fields", () => { + // #given + const config: ExternalCliBackendConfig = { + enabled: false, + provider: "cursor", + default_model: "gpt-5.1-codex", + timeout: 300000, + } + + // #then + expect(config.enabled).toBe(false) + expect(config.provider).toBe("cursor") + expect(config.models).toBeUndefined() + expect(config.workspace).toBeUndefined() + }) +}) diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index 87083aad45..ac7e253297 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -5,6 +5,7 @@ import type { BackgroundTask, LaunchInput, } from "./types" +import type { ExternalCliBackendConfig } from "../external-cli" import { log } from "../../shared/logger" import { ConcurrencyManager } from "./concurrency" import type { BackgroundTaskConfig } from "../../config/schema" @@ -13,6 +14,7 @@ import { MESSAGE_STORAGE, } from "../hook-message-injector" import { subagentSessions } from "../claude-code-session-state" +import { executeExternalCli } from "../external-cli" const TASK_TTL_MS = 30 * 60 * 1000 @@ -56,27 +58,121 @@ function getMessageDir(sessionID: string): string | null { return null } +export interface BackgroundManagerOptions { + externalCli?: ExternalCliBackendConfig +} + export class BackgroundManager { private tasks: Map private notifications: Map private client: OpencodeClient private directory: string private pollingInterval?: ReturnType + private externalCliConfig?: ExternalCliBackendConfig private concurrencyManager: ConcurrencyManager - constructor(ctx: PluginInput, config?: BackgroundTaskConfig) { + constructor(ctx: PluginInput, options?: BackgroundManagerOptions, config?: BackgroundTaskConfig) { this.tasks = new Map() this.notifications = new Map() this.client = ctx.client this.directory = ctx.directory + this.externalCliConfig = options?.externalCli this.concurrencyManager = new ConcurrencyManager(config) } + isExternalCliEnabled(): boolean { + return this.externalCliConfig?.enabled === true + } + + getExternalCliProvider(): string | undefined { + return this.externalCliConfig?.provider + } + async launch(input: LaunchInput): Promise { if (!input.agent || input.agent.trim() === "") { throw new Error("Agent parameter is required") } + if (this.isExternalCliEnabled()) { + return this.launchWithExternalCli(input) + } + + return this.launchWithOpencode(input) + } + + private async launchWithExternalCli(input: LaunchInput): Promise { + const config = this.externalCliConfig! + const model = config.models?.[input.agent] ?? config.default_model + const provider = config.provider + + const taskId = `bg_${crypto.randomUUID().slice(0, 8)}` + const sessionID = `${provider}_${taskId}` + + const task: BackgroundTask = { + id: taskId, + sessionID, + parentSessionID: input.parentSessionID, + parentMessageID: input.parentMessageID, + description: input.description, + prompt: input.prompt, + agent: input.agent, + status: "running", + startedAt: new Date(), + progress: { + toolCalls: 0, + lastUpdate: new Date(), + }, + parentModel: input.parentModel, + backend: "external-cli", + } + + this.tasks.set(task.id, task) + + log("[background-agent] Launching external-cli task:", { taskId: task.id, provider, model, agent: input.agent }) + + executeExternalCli(provider, { + model, + prompt: input.prompt, + workspace: config.workspace ?? this.directory, + timeout: config.timeout, + }).then((result) => { + const existingTask = this.getTask(taskId) + if (!existingTask) return + + if (result.success) { + existingTask.status = "completed" + existingTask.result = result.result + } else { + existingTask.status = "error" + existingTask.error = result.error + existingTask.result = result.result + } + existingTask.completedAt = new Date() + if (result.duration_ms) { + existingTask.progress = { + ...existingTask.progress!, + lastUpdate: new Date(), + } + } + this.markForNotification(existingTask) + this.notifyParentSession(existingTask) + log("[background-agent] External-cli task completed:", { taskId, provider, success: result.success }) + }).catch((error) => { + const existingTask = this.getTask(taskId) + if (!existingTask) return + + existingTask.status = "error" + existingTask.error = error instanceof Error ? error.message : String(error) + existingTask.completedAt = new Date() + this.markForNotification(existingTask) + this.notifyParentSession(existingTask) + log("[background-agent] External-cli task error:", { taskId, provider, error }) + }) + + return task + } + + private async launchWithOpencode(input: LaunchInput): Promise { const model = input.agent await this.concurrencyManager.acquire(model) @@ -114,6 +210,7 @@ export class BackgroundManager { lastUpdate: new Date(), }, parentModel: input.parentModel, + backend: "opencode", model, } diff --git a/src/features/background-agent/types.ts b/src/features/background-agent/types.ts index 8a697a0e56..b02faf0090 100644 --- a/src/features/background-agent/types.ts +++ b/src/features/background-agent/types.ts @@ -4,6 +4,8 @@ export type BackgroundTaskStatus = | "error" | "cancelled" +export type BackgroundBackend = "opencode" | "external-cli" + export interface TaskProgress { toolCalls: number lastTool?: string @@ -27,6 +29,7 @@ export interface BackgroundTask { error?: string progress?: TaskProgress parentModel?: { providerID: string; modelID: string } + backend: BackgroundBackend model?: string } @@ -38,3 +41,5 @@ export interface LaunchInput { parentMessageID: string parentModel?: { providerID: string; modelID: string } } + + diff --git a/src/features/external-cli/executor.test.ts b/src/features/external-cli/executor.test.ts new file mode 100644 index 0000000000..7bc00a048f --- /dev/null +++ b/src/features/external-cli/executor.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "bun:test" +import { isProviderAvailable, createProvider } from "./index" +import type { + ExternalCliExecuteOptions, + ExternalCliExecuteResult, + ExternalCliProviderInterface, + CursorAgentResponse, +} from "./types" + +describe("external-cli executor", () => { + describe("types", () => { + // #given CursorAgentResponse type + // #when creating a valid response object + // #then it should match the expected structure + it("CursorAgentResponse should have correct structure", () => { + const response: CursorAgentResponse = { + type: "result", + subtype: "success", + is_error: false, + duration_ms: 1000, + duration_api_ms: 900, + result: "test output", + session_id: "test-session", + request_id: "test-request", + } + + expect(response.type).toBe("result") + expect(response.is_error).toBe(false) + expect(response.result).toBe("test output") + }) + + // #given ExternalCliExecuteOptions type + // #when creating valid options + // #then it should accept all required and optional fields + it("ExternalCliExecuteOptions should have required fields", () => { + const options: ExternalCliExecuteOptions = { + model: "gpt-5.1-codex", + prompt: "test prompt", + } + + expect(options.model).toBe("gpt-5.1-codex") + expect(options.prompt).toBe("test prompt") + expect(options.workspace).toBeUndefined() + expect(options.timeout).toBeUndefined() + }) + + // #given ExternalCliExecuteResult type + // #when creating success and error results + // #then both should be valid + it("ExternalCliExecuteResult should handle success and error", () => { + const successResult: ExternalCliExecuteResult = { + success: true, + result: "output", + duration_ms: 1000, + session_id: "sess-123", + } + + const errorResult: ExternalCliExecuteResult = { + success: false, + result: "", + error: "Something went wrong", + } + + expect(successResult.success).toBe(true) + expect(errorResult.success).toBe(false) + expect(errorResult.error).toBe("Something went wrong") + }) + }) + + describe("provider factory", () => { + // #given cursor provider name + // #when creating provider + // #then it should return a valid provider instance + it("creates cursor provider", () => { + const provider = createProvider("cursor") + expect(provider).toBeDefined() + expect(provider.name).toBe("cursor") + }) + + // #given a provider interface + // #when checking its methods + // #then it should have execute and isAvailable + it("provider has required interface methods", () => { + const provider: ExternalCliProviderInterface = createProvider("cursor") + expect(typeof provider.execute).toBe("function") + expect(typeof provider.isAvailable).toBe("function") + expect(provider.name).toBe("cursor") + }) + }) + + describe("isProviderAvailable", () => { + // #given cursor provider + // #when checking availability + // #then it should return boolean without throwing + it("returns boolean without throwing", async () => { + const result = await isProviderAvailable("cursor") + expect(typeof result).toBe("boolean") + }) + }) +}) diff --git a/src/features/external-cli/executor.ts b/src/features/external-cli/executor.ts new file mode 100644 index 0000000000..488534f96c --- /dev/null +++ b/src/features/external-cli/executor.ts @@ -0,0 +1,34 @@ +import type { ExternalCliProvider } from "../../config/schema" +import type { + ExternalCliExecuteOptions, + ExternalCliExecuteResult, + ExternalCliProviderInterface, +} from "./types" +import { createProvider } from "./providers" + +let cachedProvider: ExternalCliProviderInterface | null = null +let cachedProviderName: ExternalCliProvider | null = null + +export function getProvider(name: ExternalCliProvider): ExternalCliProviderInterface { + if (cachedProvider && cachedProviderName === name) { + return cachedProvider + } + cachedProvider = createProvider(name) + cachedProviderName = name + return cachedProvider +} + +export async function executeExternalCli( + providerName: ExternalCliProvider, + options: ExternalCliExecuteOptions +): Promise { + const provider = getProvider(providerName) + return provider.execute(options) +} + +export async function isProviderAvailable(providerName: ExternalCliProvider): Promise { + const provider = getProvider(providerName) + return provider.isAvailable() +} + +export { CursorProvider } from "./providers" diff --git a/src/features/external-cli/index.ts b/src/features/external-cli/index.ts new file mode 100644 index 0000000000..733053fae8 --- /dev/null +++ b/src/features/external-cli/index.ts @@ -0,0 +1,3 @@ +export * from "./types" +export { executeExternalCli, isProviderAvailable, getProvider, CursorProvider } from "./executor" +export { createProvider } from "./providers" diff --git a/src/features/external-cli/providers/cursor.ts b/src/features/external-cli/providers/cursor.ts new file mode 100644 index 0000000000..4ae17c960a --- /dev/null +++ b/src/features/external-cli/providers/cursor.ts @@ -0,0 +1,145 @@ +import { spawn } from "node:child_process" +import type { + ExternalCliProviderInterface, + ExternalCliExecuteOptions, + ExternalCliExecuteResult, + CursorAgentResponse, +} from "../types" +import { log } from "../../../shared/logger" + +const DEFAULT_TIMEOUT = 300000 + +export class CursorProvider implements ExternalCliProviderInterface { + readonly name = "cursor" as const + + async execute(options: ExternalCliExecuteOptions): Promise { + const { model, prompt, workspace, timeout = DEFAULT_TIMEOUT } = options + + const args = [ + "-p", + "--model", model, + "--output-format", "json", + ] + + if (workspace) { + args.push("--workspace", workspace) + } + + args.push(prompt) + + log("[external-cli:cursor] Executing:", { model, promptLength: prompt.length, args }) + + return new Promise((resolve) => { + let stdout = "" + let stderr = "" + let timedOut = false + + const proc = spawn("cursor-agent", args, { + stdio: ["pipe", "pipe", "pipe"], + }) + + proc.stdin.end() + + const timeoutId = setTimeout(() => { + timedOut = true + proc.kill("SIGTERM") + }, timeout) + + proc.stdout.on("data", (data) => { + stdout += data.toString() + }) + + proc.stderr.on("data", (data) => { + stderr += data.toString() + }) + + proc.on("close", (code) => { + clearTimeout(timeoutId) + + if (timedOut) { + resolve({ + success: false, + result: "", + error: `Command timed out after ${timeout}ms`, + }) + return + } + + if (code !== 0) { + log("[external-cli:cursor] Command failed:", { code, stderr }) + resolve({ + success: false, + result: "", + error: stderr || `Process exited with code ${code}`, + }) + return + } + + try { + const response = JSON.parse(stdout.trim()) as CursorAgentResponse + log("[external-cli:cursor] Response:", { response: JSON.stringify(response, null, 2) }) + + if (response.is_error) { + resolve({ + success: false, + result: response.result, + error: response.result, + duration_ms: response.duration_ms, + session_id: response.session_id, + }) + return + } + + resolve({ + success: true, + result: response.result, + duration_ms: response.duration_ms, + session_id: response.session_id, + }) + } catch (parseError) { + log("[external-cli:cursor] Failed to parse response:", { stdout, parseError }) + resolve({ + success: false, + result: stdout, + error: `Failed to parse response: ${parseError instanceof Error ? parseError.message : String(parseError)}`, + }) + } + }) + + proc.on("error", (err) => { + clearTimeout(timeoutId) + log("[external-cli:cursor] Process error:", err) + resolve({ + success: false, + result: "", + error: `Failed to execute cursor command: ${err.message}`, + }) + }) + }) + } + + async isAvailable(): Promise { + return new Promise((resolve) => { + const proc = spawn("cursor-agent", ["--version"], { + stdio: ["pipe", "pipe", "pipe"], + }) + + proc.stdin.end() + + const timeoutId = setTimeout(() => { + proc.kill("SIGTERM") + resolve(false) + }, 5000) + + proc.on("close", (code) => { + clearTimeout(timeoutId) + resolve(code === 0) + }) + + proc.on("error", () => { + clearTimeout(timeoutId) + resolve(false) + }) + }) + } +} diff --git a/src/features/external-cli/providers/index.ts b/src/features/external-cli/providers/index.ts new file mode 100644 index 0000000000..6d25411bb5 --- /dev/null +++ b/src/features/external-cli/providers/index.ts @@ -0,0 +1,17 @@ +import type { ExternalCliProvider } from "../../../config/schema" +import type { ExternalCliProviderInterface } from "../types" +import { CursorProvider } from "./cursor" + +const providers: Record ExternalCliProviderInterface> = { + cursor: () => new CursorProvider(), +} + +export function createProvider(name: ExternalCliProvider): ExternalCliProviderInterface { + const factory = providers[name] + if (!factory) { + throw new Error(`Unknown external CLI provider: ${name}`) + } + return factory() +} + +export { CursorProvider } diff --git a/src/features/external-cli/types.ts b/src/features/external-cli/types.ts new file mode 100644 index 0000000000..2e35dd3ded --- /dev/null +++ b/src/features/external-cli/types.ts @@ -0,0 +1,42 @@ +import type { ExternalCliProvider } from "../../config/schema" + +export interface ExternalCliExecuteOptions { + model: string + prompt: string + workspace?: string + timeout?: number +} + +export interface ExternalCliExecuteResult { + success: boolean + result: string + error?: string + duration_ms?: number + session_id?: string +} + +export interface ExternalCliProviderInterface { + readonly name: ExternalCliProvider + execute(options: ExternalCliExecuteOptions): Promise + isAvailable(): Promise +} + +export interface ExternalCliBackendConfig { + enabled: boolean + provider: ExternalCliProvider + models?: Record + default_model: string + workspace?: string + timeout: number +} + +export interface CursorAgentResponse { + type: string + subtype: string + is_error: boolean + duration_ms: number + duration_api_ms: number + result: string + session_id: string + request_id: string +} diff --git a/src/index.ts b/src/index.ts index 226d6b61ac..feb0695801 100644 --- a/src/index.ts +++ b/src/index.ts @@ -185,7 +185,22 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ? createEditErrorRecoveryHook(ctx) : null; - const backgroundManager = new BackgroundManager(ctx); + const externalCliConfig = pluginConfig.external_cli + ? { + enabled: pluginConfig.external_cli.enabled ?? false, + provider: pluginConfig.external_cli.provider ?? "cursor", + models: pluginConfig.external_cli.models, + default_model: pluginConfig.external_cli.default_model ?? "gpt-5.1-codex", + workspace: pluginConfig.external_cli.workspace, + timeout: pluginConfig.external_cli.timeout ?? 300000, + } + : undefined; + + const backgroundManager = new BackgroundManager( + ctx, + { externalCli: externalCliConfig }, + pluginConfig.background_task + ); const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer") ? createTodoContinuationEnforcer(ctx, { backgroundManager }) diff --git a/src/tools/background-task/tools.ts b/src/tools/background-task/tools.ts index b9637e23a2..72d361cef6 100644 --- a/src/tools/background-task/tools.ts +++ b/src/tools/background-task/tools.ts @@ -145,13 +145,15 @@ ${truncated} > **Failed**: The task encountered an error. Check the last message for details.` } + const backendInfo = task.backend === "external-cli" ? " (external-cli)" : "" + return `# Task Status | Field | Value | |-------|-------| | Task ID | \`${task.id}\` | | Description | ${task.description} | -| Agent | ${task.agent} | +| Agent | ${task.agent}${backendInfo} | | Status | **${task.status}** | | Duration | ${duration} | | Session ID | \`${task.sessionID}\` |${progressSection} @@ -164,6 +166,23 @@ ${promptPreview} } async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): Promise { + const duration = formatDuration(task.startedAt, task.completedAt) + const backendInfo = task.backend === "external-cli" ? " (external-cli)" : "" + + if (task.backend === "external-cli") { + const resultContent = task.result || task.error || "(No output)" + return `Task Result + +Task ID: ${task.id} +Description: ${task.description} +Agent: ${task.agent}${backendInfo} +Duration: ${duration} + +--- + +${resultContent}` + } + const messagesResult = await client.session.messages({ path: { id: task.sessionID }, }) @@ -172,7 +191,6 @@ async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): P return `Error fetching messages: ${messagesResult.error}` } - // Handle both SDK response structures: direct array or wrapped in .data // eslint-disable-next-line @typescript-eslint/no-explicit-any const messages = ((messagesResult as any).data ?? messagesResult) as Array<{ info?: { role?: string } @@ -184,7 +202,7 @@ async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): P Task ID: ${task.id} Description: ${task.description} -Duration: ${formatDuration(task.startedAt, task.completedAt)} +Duration: ${duration} Session ID: ${task.sessionID} --- @@ -201,7 +219,7 @@ Session ID: ${task.sessionID} Task ID: ${task.id} Description: ${task.description} -Duration: ${formatDuration(task.startedAt, task.completedAt)} +Duration: ${duration} Session ID: ${task.sessionID} --- @@ -218,8 +236,6 @@ Session ID: ${task.sessionID} .filter((text) => text.length > 0) .join("\n") - const duration = formatDuration(task.startedAt, task.completedAt) - return `Task Result Task ID: ${task.id}