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
1 change: 1 addition & 0 deletions src/agents/explore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
mode: "subagent" as const,
model,
temperature: 0.1,
max_steps: 25,
...restrictions,
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.

Expand Down
3 changes: 2 additions & 1 deletion src/agents/librarian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, background_task: false },
max_steps: 30,
tools: { write: false, edit: false, background_task: false, task: false, sisyphus_task: false, call_omo_agent: false },
prompt: `# THE LIBRARIAN

You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.
Expand Down
107 changes: 86 additions & 21 deletions src/features/background-agent/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import { getTaskToastManager } from "../task-toast-manager"

const TASK_TTL_MS = 30 * 60 * 1000
const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in
// MIN_IDLE_TIME_MS: Minimum time before accepting session.idle as completion
// This prevents false positives when SDK emits idle before agent fully starts
const MIN_IDLE_TIME_MS = 5000
// MAX_RUN_TIME_MS: Global timeout to prevent tasks from running forever
// Reference: kdcokenny/opencode-background-agents uses 15 minutes
const MAX_RUN_TIME_MS = 15 * 60 * 1000

type OpencodeClient = PluginInput["client"]

Expand Down Expand Up @@ -170,6 +176,7 @@ export class BackgroundManager {
existingTask.completedAt = new Date()
if (existingTask.concurrencyKey) {
this.concurrencyManager.release(existingTask.concurrencyKey)
existingTask.concurrencyKey = undefined // Prevent double-release
}
this.markForNotification(existingTask)
this.notifyParentSession(existingTask).catch(err => {
Expand All @@ -178,6 +185,31 @@ export class BackgroundManager {
}
})

// Global timeout: Prevent tasks from running forever (15 min max)
// Reference: kdcokenny/opencode-background-agents uses same pattern
const timeout = setTimeout(() => {
const currentTask = this.tasks.get(task.id)
if (currentTask && currentTask.status === "running") {
log("[background-agent] Task timed out after 15 minutes:", task.id)
currentTask.status = "error"
currentTask.error = `Task timed out after ${MAX_RUN_TIME_MS / 1000 / 60} minutes`
currentTask.completedAt = new Date()
// Clear timeout timer first to prevent double-release
currentTask.timeoutTimer = undefined
if (currentTask.concurrencyKey) {
this.concurrencyManager.release(currentTask.concurrencyKey)
currentTask.concurrencyKey = undefined // Prevent double-release
}
this.markForNotification(currentTask)
this.notifyParentSession(currentTask).catch(err => {
log("[background-agent] Failed to notify on timeout:", err)
})
}
}, MAX_RUN_TIME_MS)
// Prevent timeout from keeping the event loop alive
timeout.unref?.()
task.timeoutTimer = timeout

return task
}

Expand Down Expand Up @@ -273,6 +305,9 @@ export class BackgroundManager {
existingTask.parentMessageID = input.parentMessageID
existingTask.parentModel = input.parentModel
existingTask.parentAgent = input.parentAgent
// P2 fix: Reset startedAt on resume to prevent immediate completion
// The MIN_IDLE_TIME_MS check uses startedAt, so resumed tasks need fresh timing
existingTask.startedAt = new Date()

existingTask.progress = {
toolCalls: existingTask.progress?.toolCalls ?? 0,
Expand All @@ -299,6 +334,32 @@ export class BackgroundManager {

log("[background-agent] Resuming task:", { taskId: existingTask.id, sessionID: existingTask.sessionID })

// Clear any existing timeout and create a new one for the resumed task
if (existingTask.timeoutTimer) {
clearTimeout(existingTask.timeoutTimer)
existingTask.timeoutTimer = undefined
}
const timeout = setTimeout(() => {
const currentTask = this.tasks.get(existingTask.id)
if (currentTask && currentTask.status === "running") {
log("[background-agent] Resumed task timed out after 15 minutes:", existingTask.id)
currentTask.status = "error"
currentTask.error = `Task timed out after ${MAX_RUN_TIME_MS / 1000 / 60} minutes`
currentTask.completedAt = new Date()
currentTask.timeoutTimer = undefined
if (currentTask.concurrencyKey) {
this.concurrencyManager.release(currentTask.concurrencyKey)
currentTask.concurrencyKey = undefined
}
this.markForNotification(currentTask)
this.notifyParentSession(currentTask).catch(err => {
log("[background-agent] Failed to notify on resume timeout:", err)
})
}
}, MAX_RUN_TIME_MS)
timeout.unref?.()
existingTask.timeoutTimer = timeout

log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
sessionID: existingTask.sessionID,
agent: existingTask.agent,
Expand All @@ -323,6 +384,16 @@ export class BackgroundManager {
const errorMessage = error instanceof Error ? error.message : String(error)
existingTask.error = errorMessage
existingTask.completedAt = new Date()
// Clean up timeout timer created for this resume
if (existingTask.timeoutTimer) {
clearTimeout(existingTask.timeoutTimer)
existingTask.timeoutTimer = undefined
}
// Release concurrency key to unblock queued tasks
if (existingTask.concurrencyKey) {
this.concurrencyManager.release(existingTask.concurrencyKey)
existingTask.concurrencyKey = undefined
}
this.markForNotification(existingTask)
this.notifyParentSession(existingTask).catch(err => {
log("[background-agent] Failed to notify on resume error:", err)
Expand Down Expand Up @@ -383,33 +454,25 @@ export class BackgroundManager {

// Edge guard: Require minimum elapsed time (5 seconds) before accepting idle
const elapsedMs = Date.now() - task.startedAt.getTime()
const MIN_IDLE_TIME_MS = 5000
if (elapsedMs < MIN_IDLE_TIME_MS) {
log("[background-agent] Ignoring early session.idle, elapsed:", { elapsedMs, taskId: task.id })
return
}

// Edge guard: Verify session has actual assistant output before completing
this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => {
if (!hasValidOutput) {
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
return
}

const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
if (hasIncompleteTodos) {
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
return
}

task.status = "completed"
task.completedAt = new Date()
this.markForNotification(task)
await this.notifyParentSession(task)
log("[background-agent] Task completed via session.idle event:", task.id)
}).catch(err => {
log("[background-agent] Error in session.idle handler:", err)
// SIMPLIFIED: Mark complete immediately on session.idle (after min time)
// Reference: kdcokenny/opencode-background-agents uses same pattern
// Previous guards (validateSessionHasOutput, checkSessionTodos) were causing stuck tasks
if (task.timeoutTimer) {
clearTimeout(task.timeoutTimer)
task.timeoutTimer = undefined
}
task.status = "completed"
task.completedAt = new Date()
this.markForNotification(task)
this.notifyParentSession(task).catch(err => {
log("[background-agent] Error notifying parent on completion:", err)
})
log("[background-agent] Task completed via session.idle event:", task.id)
}

if (event.type === "session.deleted") {
Expand All @@ -428,6 +491,7 @@ export class BackgroundManager {

if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined // Prevent double-release
}
this.tasks.delete(task.id)
this.clearNotificationsForTask(task.id)
Expand Down Expand Up @@ -684,6 +748,7 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
task.completedAt = new Date()
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)
task.concurrencyKey = undefined // Prevent double-release
}
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
Expand Down
2 changes: 2 additions & 0 deletions src/features/background-agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ export interface BackgroundTask {
lastMsgCount?: number
/** Number of consecutive polls with stable message count */
stablePolls?: number
/** Timeout timer reference for cleanup on completion */
timeoutTimer?: ReturnType<typeof setTimeout>
}

export interface LaunchInput {
Expand Down
28 changes: 22 additions & 6 deletions src/shared/config-path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,20 @@ import * as fs from "fs"
export function getUserConfigDir(): string {
if (process.platform === "win32") {
const crossPlatformDir = path.join(os.homedir(), ".config")
const crossPlatformConfigPath = path.join(crossPlatformDir, "opencode", "oh-my-opencode.json")
// Check JSONC first, then JSON
const crossPlatformConfigPathJsonc = path.join(crossPlatformDir, "opencode", "oh-my-opencode.jsonc")
const crossPlatformConfigPathJson = path.join(crossPlatformDir, "opencode", "oh-my-opencode.json")

const appdataDir = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming")
const appdataConfigPath = path.join(appdataDir, "opencode", "oh-my-opencode.json")
const appdataConfigPathJsonc = path.join(appdataDir, "opencode", "oh-my-opencode.jsonc")
const appdataConfigPathJson = path.join(appdataDir, "opencode", "oh-my-opencode.json")

if (fs.existsSync(crossPlatformConfigPath)) {
// Priority: ~/.config (JSONC > JSON) > %APPDATA% (JSONC > JSON)
if (fs.existsSync(crossPlatformConfigPathJsonc) || fs.existsSync(crossPlatformConfigPathJson)) {
return crossPlatformDir
}

if (fs.existsSync(appdataConfigPath)) {
if (fs.existsSync(appdataConfigPathJsonc) || fs.existsSync(appdataConfigPathJson)) {
return appdataDir
}

Expand All @@ -34,14 +38,26 @@ export function getUserConfigDir(): string {

/**
* Returns the full path to the user-level oh-my-opencode config file.
* Checks for .jsonc first, then .json
*/
export function getUserConfigPath(): string {
return path.join(getUserConfigDir(), "opencode", "oh-my-opencode.json")
const dir = path.join(getUserConfigDir(), "opencode")
const jsoncPath = path.join(dir, "oh-my-opencode.jsonc")
if (fs.existsSync(jsoncPath)) {
return jsoncPath
}
return path.join(dir, "oh-my-opencode.json")
}

/**
* Returns the full path to the project-level oh-my-opencode config file.
* Checks for .jsonc first, then .json
*/
export function getProjectConfigPath(directory: string): string {
return path.join(directory, ".opencode", "oh-my-opencode.json")
const dir = path.join(directory, ".opencode")
const jsoncPath = path.join(dir, "oh-my-opencode.jsonc")
if (fs.existsSync(jsoncPath)) {
return jsoncPath
}
return path.join(dir, "oh-my-opencode.json")
}
37 changes: 23 additions & 14 deletions src/tools/sisyphus-task/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -419,21 +419,30 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
metadata: { sessionId: sessionID, category: args.category, sync: true },
})

try {
await client.session.prompt({
path: { id: sessionID },
body: {
agent: agentToUse,
system: systemContent,
tools: {
task: false,
sisyphus_task: false,
},
parts: [{ type: "text", text: args.prompt }],
...(categoryModel ? { model: categoryModel } : {}),
// Use fire-and-forget prompt() - awaiting causes JSON parse errors with thinking models
// For category-based tasks, pass the model from category config
// For agent-based tasks, use agent's configured model (don't pass model in body)
let promptError: Error | undefined
client.session.prompt({
Copy link

@cubic-dev-ai cubic-dev-ai bot Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Late prompt rejections are dropped: fire-and-forget prompt is only checked for 100 ms, so failures after that window lead to 10‑minute polling and a misleading "No assistant response found" when the prompt was never sent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tools/sisyphus-task/tools.ts, line 426:

<comment>Late prompt rejections are dropped: fire-and-forget prompt is only checked for 100 ms, so failures after that window lead to 10‑minute polling and a misleading "No assistant response found" when the prompt was never sent.</comment>

<file context>
@@ -419,21 +419,30 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
+        // For category-based tasks, pass the model from category config
+        // For agent-based tasks, use agent's configured model (don't pass model in body)
+        let promptError: Error | undefined
+        client.session.prompt({
+          path: { id: sessionID },
+          body: {
</file context>
Fix with Cubic

path: { id: sessionID },
body: {
agent: agentToUse,
system: systemContent,
tools: {
task: false,
sisyphus_task: false,
},
})
} catch (promptError) {
parts: [{ type: "text", text: args.prompt }],
...(categoryModel ? { model: categoryModel } : {}),
},
}).catch((error) => {
promptError = error instanceof Error ? error : new Error(String(error))
})

// Small delay to let the prompt start
await new Promise(resolve => setTimeout(resolve, 100))

if (promptError) {
if (toastManager && taskId !== undefined) {
toastManager.removeTask(taskId)
}
Comment on lines +445 to 448
Copy link

@cubic-dev-ai cubic-dev-ai bot Jan 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Prompt error early return leaves subagentSessions entry uncleared, causing stale session tracking

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/tools/sisyphus-task/tools.ts, line 445:

<comment>Prompt error early return leaves subagentSessions entry uncleared, causing stale session tracking</comment>

<file context>
@@ -419,21 +419,30 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
+        // Small delay to let the prompt start
+        await new Promise(resolve => setTimeout(resolve, 100))
+
+        if (promptError) {
           if (toastManager && taskId !== undefined) {
             toastManager.removeTask(taskId)
</file context>
Fix with Cubic

Expand Down