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
57 changes: 57 additions & 0 deletions assets/oh-my-opencode.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2060,6 +2060,18 @@
},
"prompt_append": {
"type": "string"
},
"initial_wait_ms": {
"type": "number",
"minimum": 1000
},
"poll_interval_ms": {
"type": "number",
"minimum": 500
},
"timeout_ms": {
"type": "number",
"minimum": 30000
}
},
"required": [
Expand Down Expand Up @@ -2405,6 +2417,18 @@
"type": "number",
"minimum": 1
}
},
"default_initial_wait_ms": {
"type": "number",
"minimum": 1000
},
"default_poll_interval_ms": {
"type": "number",
"minimum": 500
},
"default_timeout_ms": {
"type": "number",
"minimum": 30000
}
}
},
Expand All @@ -2428,6 +2452,39 @@
"type": "boolean"
}
}
},
"keyword_modes": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"properties": {
"pattern": {
"type": "string"
},
"prompt": {
"type": "string"
},
"prompt_file": {
"type": "string"
},
"show_toast": {
"default": true,
"type": "boolean"
},
"toast_title": {
"type": "string"
},
"toast_message": {
"type": "string"
}
},
"required": [
"pattern"
]
}
}
}
}
30 changes: 30 additions & 0 deletions src/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,12 @@ export const CategoryConfigSchema = z.object({
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
tools: z.record(z.string(), z.boolean()).optional(),
prompt_append: z.string().optional(),
/** Wait before first poll in ms (default: 300000 = 5 min) */
initial_wait_ms: z.number().min(1000).optional(),
/** Polling interval in ms after initial wait (default: 60000 = 60 sec) */
poll_interval_ms: z.number().min(500).optional(),
/** Hard timeout in ms - task killed after this (default: 900000 = 15 min) */
timeout_ms: z.number().min(30000).optional(),
})

export const BuiltinCategoryNameSchema = z.enum([
Expand Down Expand Up @@ -287,13 +293,34 @@ export const BackgroundTaskConfigSchema = z.object({
defaultConcurrency: z.number().min(1).optional(),
providerConcurrency: z.record(z.string(), z.number().min(1)).optional(),
modelConcurrency: z.record(z.string(), z.number().min(1)).optional(),
/** Wait before first poll in ms (default: 300000 = 5 min) */
default_initial_wait_ms: z.number().min(1000).optional(),
/** Polling interval in ms (default: 60000 = 60 sec) */
default_poll_interval_ms: z.number().min(500).optional(),
/** Hard timeout in ms (default: 900000 = 15 min) */
default_timeout_ms: z.number().min(30000).optional(),
})

export const NotificationConfigSchema = z.object({
/** Force enable session-notification even if external notification plugins are detected (default: false) */
force_enable: z.boolean().optional(),
})

export const KeywordModeConfigSchema = z.object({
/** Regex pattern to match (case-insensitive by default) */
pattern: z.string(),
/** Inline prompt to inject when keyword is detected */
prompt: z.string().optional(),
/** Path to external .md file containing the prompt (supports ~ expansion) */
prompt_file: z.string().optional(),
/** Whether to show toast notification when mode activates (default: true) */
show_toast: z.boolean().default(true),
/** Custom toast title */
toast_title: z.string().optional(),
/** Custom toast message */
toast_message: z.string().optional(),
})

export const GitMasterConfigSchema = z.object({
/** Add "Ultraworked with Sisyphus" footer to commit messages (default: true) */
commit_footer: z.boolean().default(true),
Expand All @@ -320,6 +347,8 @@ export const OhMyOpenCodeConfigSchema = z.object({
background_task: BackgroundTaskConfigSchema.optional(),
notification: NotificationConfigSchema.optional(),
git_master: GitMasterConfigSchema.optional(),
/** Custom keyword modes - define your own triggers and prompts */
keyword_modes: z.record(z.string(), KeywordModeConfigSchema).optional(),
})

export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
Expand All @@ -342,5 +371,6 @@ export type CategoryConfig = z.infer<typeof CategoryConfigSchema>
export type CategoriesConfig = z.infer<typeof CategoriesConfigSchema>
export type BuiltinCategoryName = z.infer<typeof BuiltinCategoryNameSchema>
export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema>
export type KeywordModeConfig = z.infer<typeof KeywordModeConfigSchema>

export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"
79 changes: 64 additions & 15 deletions src/features/background-agent/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,10 @@ import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../hook-message-i
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"

const TASK_TTL_MS = 30 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in
const DEFAULT_INITIAL_WAIT_MS = 5 * 60 * 1000 // 5 minutes
const DEFAULT_POLL_INTERVAL_MS = 60 * 1000 // 60 seconds
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000 // 15 minutes
const MIN_STABILITY_TIME_MS = 10 * 1000

type OpencodeClient = PluginInput["client"]

Expand Down Expand Up @@ -52,6 +54,9 @@ export class BackgroundManager {
private directory: string
private pollingInterval?: ReturnType<typeof setInterval>
private concurrencyManager: ConcurrencyManager
private defaultInitialWaitMs: number
private defaultPollIntervalMs: number
private defaultTimeoutMs: number

constructor(ctx: PluginInput, config?: BackgroundTaskConfig) {
this.tasks = new Map()
Expand All @@ -60,6 +65,9 @@ export class BackgroundManager {
this.client = ctx.client
this.directory = ctx.directory
this.concurrencyManager = new ConcurrencyManager(config)
this.defaultInitialWaitMs = config?.default_initial_wait_ms ?? DEFAULT_INITIAL_WAIT_MS
this.defaultPollIntervalMs = config?.default_poll_interval_ms ?? DEFAULT_POLL_INTERVAL_MS
this.defaultTimeoutMs = config?.default_timeout_ms ?? DEFAULT_TIMEOUT_MS
}

async launch(input: LaunchInput): Promise<BackgroundTask> {
Expand Down Expand Up @@ -126,6 +134,9 @@ export class BackgroundManager {
parentAgent: input.parentAgent,
model: input.model,
concurrencyKey,
initial_wait_ms: input.initial_wait_ms ?? this.defaultInitialWaitMs,
poll_interval_ms: input.poll_interval_ms ?? this.defaultPollIntervalMs,
timeout_ms: input.timeout_ms ?? this.defaultTimeoutMs,
}

this.tasks.set(task.id, task)
Expand Down Expand Up @@ -173,16 +184,33 @@ export class BackgroundManager {
parts: [{ type: "text", text: input.prompt }],
},
}).catch((error) => {
log("[background-agent] promptAsync error:", error)
const errorMessage = error instanceof Error ? error.message : String(error)
log("[background-agent] prompt error:", {
sessionID,
agent: input.agent,
errorName: error?.name,
errorMessage,
})

// Only treat as fatal if it's clearly an agent/session creation issue.
// Network timeouts, connection drops, and SDK issues should NOT mark task as failed
// since the session may still be running successfully (common during high API load).
const isFatalError =
errorMessage.includes("agent.name") ||
errorMessage.includes("undefined") ||
errorMessage.includes("not found") ||
errorMessage.includes("not registered") ||
errorMessage.includes("does not exist")

if (!isFatalError) {
log("[background-agent] Non-fatal prompt error, letting polling detect actual status:", sessionID)
return
}

const existingTask = this.findBySession(sessionID)
if (existingTask) {
existingTask.status = "error"
const errorMessage = error instanceof Error ? error.message : String(error)
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
existingTask.error = `Agent "${input.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`
} else {
existingTask.error = errorMessage
}
existingTask.error = `Agent "${input.agent}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.`
existingTask.completedAt = new Date()
if (existingTask.concurrencyKey) {
this.concurrencyManager.release(existingTask.concurrencyKey)
Expand Down Expand Up @@ -540,12 +568,23 @@ export class BackgroundManager {
private startPolling(): void {
if (this.pollingInterval) return

const minPollInterval = this.getMinPollInterval()
this.pollingInterval = setInterval(() => {
this.pollRunningTasks()
}, 2000)
}, minPollInterval)
this.pollingInterval.unref()
}

private getMinPollInterval(): number {
let minInterval = this.defaultPollIntervalMs
for (const task of this.tasks.values()) {
if (task.status === "running" && task.poll_interval_ms) {
minInterval = Math.min(minInterval, task.poll_interval_ms)
}
}
return minInterval
}

private stopPolling(): void {
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
Expand Down Expand Up @@ -713,15 +752,20 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea

for (const [taskId, task] of this.tasks.entries()) {
const age = now - task.startedAt.getTime()
if (age > TASK_TTL_MS) {
log("[background-agent] Pruning stale task:", { taskId, age: Math.round(age / 1000) + "s" })
const taskTimeout = task.timeout_ms ?? this.defaultTimeoutMs
if (age > taskTimeout) {
const timeoutMinutes = Math.round(taskTimeout / 60000)
log("[background-agent] Pruning stale task:", { taskId, age: Math.round(age / 1000) + "s", timeout: timeoutMinutes + "m" })
task.status = "error"
task.error = "Task timed out after 30 minutes"
task.error = `Task timed out after ${timeoutMinutes} minutes`
task.completedAt = new Date()
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
}
this.clearNotificationsForTask(taskId)
this.markForNotification(task)
this.notifyParentSession(task).catch(err => {
log("[background-agent] Failed to notify on timeout:", err)
})
this.tasks.delete(taskId)
subagentSessions.delete(task.sessionID)
}
Expand All @@ -734,7 +778,8 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
}
const validNotifications = notifications.filter((task) => {
const age = now - task.startedAt.getTime()
return age <= TASK_TTL_MS
const taskTimeout = task.timeout_ms ?? this.defaultTimeoutMs
return age <= taskTimeout
})
if (validNotifications.length === 0) {
this.notifications.delete(sessionID)
Expand All @@ -753,6 +798,10 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
for (const task of this.tasks.values()) {
if (task.status !== "running") continue

const age = Date.now() - task.startedAt.getTime()
const initialWait = task.initial_wait_ms ?? this.defaultInitialWaitMs
if (age < initialWait) continue

try {
const sessionStatus = allStatuses[task.sessionID]

Expand Down
9 changes: 9 additions & 0 deletions src/features/background-agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ export interface BackgroundTask {
lastMsgCount?: number
/** Number of consecutive polls with stable message count */
stablePolls?: number
/** Initial wait before polling in ms */
initial_wait_ms?: number
/** Polling interval in ms */
poll_interval_ms?: number
/** Hard timeout in ms */
timeout_ms?: number
}

export interface LaunchInput {
Expand All @@ -49,6 +55,9 @@ export interface LaunchInput {
model?: { providerID: string; modelID: string; variant?: string }
skills?: string[]
skillContent?: string
initial_wait_ms?: number
poll_interval_ms?: number
timeout_ms?: number
}

export interface ResumeInput {
Expand Down
Loading