Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
Expand Down
38 changes: 38 additions & 0 deletions assets/oh-my-opencode.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
20 changes: 20 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
})
Expand All @@ -278,6 +296,8 @@ export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningCo
export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
export type ExternalCliProvider = z.infer<typeof ExternalCliProviderSchema>
export type ExternalCliConfig = z.infer<typeof ExternalCliConfigSchema>
export type NotificationConfig = z.infer<typeof NotificationConfigSchema>

export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"
59 changes: 59 additions & 0 deletions src/features/background-agent/manager.test.ts
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -115,6 +116,7 @@ function createMockTask(overrides: Partial<BackgroundTask> & { id: string; sessi
agent: "test-agent",
status: "running",
startedAt: new Date(),
backend: "opencode",
...overrides,
}
}
Expand Down Expand Up @@ -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()
})
})
99 changes: 98 additions & 1 deletion src/features/background-agent/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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

Expand Down Expand Up @@ -56,27 +58,121 @@ function getMessageDir(sessionID: string): string | null {
return null
}

export interface BackgroundManagerOptions {
externalCli?: ExternalCliBackendConfig
}

export class BackgroundManager {
private tasks: Map<string, BackgroundTask>
private notifications: Map<string, BackgroundTask[]>
private client: OpencodeClient
private directory: string
private pollingInterval?: ReturnType<typeof setInterval>
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<BackgroundTask> {
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<BackgroundTask> {
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<BackgroundTask> {
const model = input.agent

await this.concurrencyManager.acquire(model)
Expand Down Expand Up @@ -114,6 +210,7 @@ export class BackgroundManager {
lastUpdate: new Date(),
},
parentModel: input.parentModel,
backend: "opencode",
model,
}

Expand Down
5 changes: 5 additions & 0 deletions src/features/background-agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export type BackgroundTaskStatus =
| "error"
| "cancelled"

export type BackgroundBackend = "opencode" | "external-cli"

export interface TaskProgress {
toolCalls: number
lastTool?: string
Expand All @@ -27,6 +29,7 @@ export interface BackgroundTask {
error?: string
progress?: TaskProgress
parentModel?: { providerID: string; modelID: string }
backend: BackgroundBackend
model?: string
}

Expand All @@ -38,3 +41,5 @@ export interface LaunchInput {
parentMessageID: string
parentModel?: { providerID: string; modelID: string }
}


Loading
Loading