Skip to content

Commit 33b5227

Browse files
committed
fix: add subagent safety guards and apply PR code-yeongyu#655 changes
- Add max_steps limit to explore (25) and librarian (30) agents - Block sisyphus_task/call_omo_agent tools in librarian to prevent spawning - Add global 15-minute timeout for background tasks (MAX_RUN_TIME_MS) - Simplify session.idle handler - remove validateSessionHasOutput/checkSessionTodos guards - Add JSONC config file support (.jsonc checked before .json) - Fix categoryModel passing in sisyphus_task sync mode Reference: PR code-yeongyu#655, kdcokenny/opencode-background-agents
1 parent f9dca8d commit 33b5227

File tree

5 files changed

+62
-29
lines changed

5 files changed

+62
-29
lines changed

src/agents/explore.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
3939
mode: "subagent" as const,
4040
model,
4141
temperature: 0.1,
42+
max_steps: 25,
4243
...restrictions,
4344
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
4445

src/agents/librarian.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig
2727
mode: "subagent" as const,
2828
model,
2929
temperature: 0.1,
30-
tools: { write: false, edit: false, background_task: false },
30+
max_steps: 30,
31+
tools: { write: false, edit: false, background_task: false, task: false, sisyphus_task: false, call_omo_agent: false },
3132
prompt: `# THE LIBRARIAN
3233
3334
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.

src/features/background-agent/manager.ts

Lines changed: 34 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ import { getTaskToastManager } from "../task-toast-manager"
1414

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

1824
type OpencodeClient = PluginInput["client"]
1925

@@ -178,6 +184,25 @@ export class BackgroundManager {
178184
}
179185
})
180186

187+
// Global timeout: Prevent tasks from running forever (15 min max)
188+
// Reference: kdcokenny/opencode-background-agents uses same pattern
189+
setTimeout(() => {
190+
const currentTask = this.tasks.get(task.id)
191+
if (currentTask && currentTask.status === "running") {
192+
log("[background-agent] Task timed out after 15 minutes:", task.id)
193+
currentTask.status = "error"
194+
currentTask.error = `Task timed out after ${MAX_RUN_TIME_MS / 1000 / 60} minutes`
195+
currentTask.completedAt = new Date()
196+
if (currentTask.concurrencyKey) {
197+
this.concurrencyManager.release(currentTask.concurrencyKey)
198+
}
199+
this.markForNotification(currentTask)
200+
this.notifyParentSession(currentTask).catch(err => {
201+
log("[background-agent] Failed to notify on timeout:", err)
202+
})
203+
}
204+
}, MAX_RUN_TIME_MS)
205+
181206
return task
182207
}
183208

@@ -383,33 +408,21 @@ export class BackgroundManager {
383408

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

392-
// Edge guard: Verify session has actual assistant output before completing
393-
this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => {
394-
if (!hasValidOutput) {
395-
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
396-
return
397-
}
398-
399-
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
400-
if (hasIncompleteTodos) {
401-
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
402-
return
403-
}
404-
405-
task.status = "completed"
406-
task.completedAt = new Date()
407-
this.markForNotification(task)
408-
await this.notifyParentSession(task)
409-
log("[background-agent] Task completed via session.idle event:", task.id)
410-
}).catch(err => {
411-
log("[background-agent] Error in session.idle handler:", err)
416+
// SIMPLIFIED: Mark complete immediately on session.idle (after min time)
417+
// Reference: kdcokenny/opencode-background-agents uses same pattern
418+
// Previous guards (validateSessionHasOutput, checkSessionTodos) were causing stuck tasks
419+
task.status = "completed"
420+
task.completedAt = new Date()
421+
this.markForNotification(task)
422+
this.notifyParentSession(task).catch(err => {
423+
log("[background-agent] Error notifying parent on completion:", err)
412424
})
425+
log("[background-agent] Task completed via session.idle event:", task.id)
413426
}
414427

415428
if (event.type === "session.deleted") {

src/shared/config-path.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,20 @@ import * as fs from "fs"
1313
export function getUserConfigDir(): string {
1414
if (process.platform === "win32") {
1515
const crossPlatformDir = path.join(os.homedir(), ".config")
16-
const crossPlatformConfigPath = path.join(crossPlatformDir, "opencode", "oh-my-opencode.json")
16+
// Check JSONC first, then JSON
17+
const crossPlatformConfigPathJsonc = path.join(crossPlatformDir, "opencode", "oh-my-opencode.jsonc")
18+
const crossPlatformConfigPathJson = path.join(crossPlatformDir, "opencode", "oh-my-opencode.json")
1719

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

21-
if (fs.existsSync(crossPlatformConfigPath)) {
24+
// Priority: ~/.config (JSONC > JSON) > %APPDATA% (JSONC > JSON)
25+
if (fs.existsSync(crossPlatformConfigPathJsonc) || fs.existsSync(crossPlatformConfigPathJson)) {
2226
return crossPlatformDir
2327
}
2428

25-
if (fs.existsSync(appdataConfigPath)) {
29+
if (fs.existsSync(appdataConfigPathJsonc) || fs.existsSync(appdataConfigPathJson)) {
2630
return appdataDir
2731
}
2832

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

3539
/**
3640
* Returns the full path to the user-level oh-my-opencode config file.
41+
* Checks for .jsonc first, then .json
3742
*/
3843
export function getUserConfigPath(): string {
39-
return path.join(getUserConfigDir(), "opencode", "oh-my-opencode.json")
44+
const dir = path.join(getUserConfigDir(), "opencode")
45+
const jsoncPath = path.join(dir, "oh-my-opencode.jsonc")
46+
if (fs.existsSync(jsoncPath)) {
47+
return jsoncPath
48+
}
49+
return path.join(dir, "oh-my-opencode.json")
4050
}
4151

4252
/**
4353
* Returns the full path to the project-level oh-my-opencode config file.
54+
* Checks for .jsonc first, then .json
4455
*/
4556
export function getProjectConfigPath(directory: string): string {
46-
return path.join(directory, ".opencode", "oh-my-opencode.json")
57+
const dir = path.join(directory, ".opencode")
58+
const jsoncPath = path.join(dir, "oh-my-opencode.jsonc")
59+
if (fs.existsSync(jsoncPath)) {
60+
return jsoncPath
61+
}
62+
return path.join(dir, "oh-my-opencode.json")
4763
}

src/tools/sisyphus-task/tools.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,8 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
420420
})
421421

422422
// Use fire-and-forget prompt() - awaiting causes JSON parse errors with thinking models
423-
// Note: Don't pass model in body - use agent's configured model instead
423+
// For category-based tasks, pass the model from category config
424+
// For agent-based tasks, use agent's configured model (don't pass model in body)
424425
let promptError: Error | undefined
425426
client.session.prompt({
426427
path: { id: sessionID },
@@ -432,6 +433,7 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
432433
sisyphus_task: false,
433434
},
434435
parts: [{ type: "text", text: args.prompt }],
436+
...(categoryModel ? { model: categoryModel } : {}),
435437
},
436438
}).catch((error) => {
437439
promptError = error instanceof Error ? error : new Error(String(error))

0 commit comments

Comments
 (0)