2026-01-05 13:50:39 +09:00
import { tool , type PluginInput , type ToolDefinition } from "@opencode-ai/plugin"
import { existsSync , readdirSync } from "node:fs"
import { join } from "node:path"
import type { BackgroundManager } from "../../features/background-agent"
import type { SisyphusTaskArgs } from "./types"
import type { CategoryConfig , CategoriesConfig } from "../../config/schema"
import { SISYPHUS_TASK_DESCRIPTION , DEFAULT_CATEGORIES , CATEGORY_PROMPT_APPENDS } from "./constants"
import { findNearestMessageWithFields , MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { resolveMultipleSkills } from "../../features/opencode-skill-loader/skill-content"
import { createBuiltinSkills } from "../../features/builtin-skills/skills"
2026-01-06 13:40:27 +09:00
import { getTaskToastManager } from "../../features/task-toast-manager"
2026-01-05 13:50:39 +09:00
type OpencodeClient = PluginInput [ "client" ]
const SISYPHUS_JUNIOR_AGENT = "Sisyphus-Junior"
const CATEGORY_EXAMPLES = Object . keys ( DEFAULT_CATEGORIES ) . map ( k = > ` ' ${ k } ' ` ) . join ( ", " )
function parseModelString ( model : string ) : { providerID : string ; modelID : string } | undefined {
const parts = model . split ( "/" )
if ( parts . length >= 2 ) {
return { providerID : parts [ 0 ] , modelID : parts.slice ( 1 ) . join ( "/" ) }
}
return undefined
}
function getMessageDir ( sessionID : string ) : string | null {
if ( ! existsSync ( MESSAGE_STORAGE ) ) return null
const directPath = join ( MESSAGE_STORAGE , sessionID )
if ( existsSync ( directPath ) ) return directPath
for ( const dir of readdirSync ( MESSAGE_STORAGE ) ) {
const sessionPath = join ( MESSAGE_STORAGE , dir , sessionID )
if ( existsSync ( sessionPath ) ) return sessionPath
}
return null
}
function formatDuration ( start : Date , end? : Date ) : string {
const duration = ( end ? ? new Date ( ) ) . getTime ( ) - start . getTime ( )
const seconds = Math . floor ( duration / 1000 )
const minutes = Math . floor ( seconds / 60 )
const hours = Math . floor ( minutes / 60 )
if ( hours > 0 ) return ` ${ hours } h ${ minutes % 60 } m ${ seconds % 60 } s `
if ( minutes > 0 ) return ` ${ minutes } m ${ seconds % 60 } s `
return ` ${ seconds } s `
}
type ToolContextWithMetadata = {
sessionID : string
messageID : string
agent : string
abort : AbortSignal
metadata ? : ( input : { title? : string ; metadata? : Record < string , unknown > } ) = > void
}
function resolveCategoryConfig (
categoryName : string ,
userCategories? : CategoriesConfig
) : { config : CategoryConfig ; promptAppend : string } | null {
const defaultConfig = DEFAULT_CATEGORIES [ categoryName ]
const userConfig = userCategories ? . [ categoryName ]
const defaultPromptAppend = CATEGORY_PROMPT_APPENDS [ categoryName ] ? ? ""
if ( ! defaultConfig && ! userConfig ) {
return null
}
const config : CategoryConfig = {
. . . defaultConfig ,
. . . userConfig ,
model : userConfig?.model ? ? defaultConfig ? . model ? ? "anthropic/claude-sonnet-4-5" ,
}
let promptAppend = defaultPromptAppend
if ( userConfig ? . prompt_append ) {
promptAppend = defaultPromptAppend
? defaultPromptAppend + "\n\n" + userConfig.prompt_append
: userConfig.prompt_append
}
return { config , promptAppend }
}
export interface SisyphusTaskToolOptions {
manager : BackgroundManager
client : OpencodeClient
userCategories? : CategoriesConfig
}
2026-01-08 13:03:28 +09:00
export interface BuildSystemContentInput {
skillContent? : string
categoryPromptAppend? : string
}
export function buildSystemContent ( input : BuildSystemContentInput ) : string | undefined {
const { skillContent , categoryPromptAppend } = input
if ( ! skillContent && ! categoryPromptAppend ) {
return undefined
}
if ( skillContent && categoryPromptAppend ) {
return ` ${ skillContent } \ n \ n ${ categoryPromptAppend } `
}
return skillContent || categoryPromptAppend
}
2026-01-05 13:50:39 +09:00
export function createSisyphusTask ( options : SisyphusTaskToolOptions ) : ToolDefinition {
const { manager , client , userCategories } = options
return tool ( {
description : SISYPHUS_TASK_DESCRIPTION ,
args : {
description : tool.schema.string ( ) . describe ( "Short task description" ) ,
prompt : tool.schema.string ( ) . describe ( "Full detailed prompt for the agent" ) ,
2026-01-06 15:16:57 +09:00
category : tool.schema.string ( ) . optional ( ) . describe ( ` Category name (e.g., ${ CATEGORY_EXAMPLES } ). Mutually exclusive with subagent_type. ` ) ,
subagent_type : tool.schema.string ( ) . optional ( ) . describe ( "Agent name directly (e.g., 'oracle', 'explore'). Mutually exclusive with category." ) ,
2026-01-08 13:39:46 +09:00
run_in_background : tool.schema.boolean ( ) . describe ( "Run in background. MUST be explicitly set. Use false for task delegation, true only for parallel exploration." ) ,
2026-01-05 13:50:39 +09:00
resume : tool.schema.string ( ) . optional ( ) . describe ( "Session ID to resume - continues previous agent session with full context" ) ,
skills : tool.schema.array ( tool . schema . string ( ) ) . optional ( ) . describe ( "Array of skill names to prepend to the prompt. Skills will be resolved and their content prepended with a separator." ) ,
} ,
async execute ( args : SisyphusTaskArgs , toolContext ) {
const ctx = toolContext as ToolContextWithMetadata
2026-01-08 13:39:46 +09:00
if ( args . run_in_background === undefined ) {
return ` ❌ Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation, run_in_background=true only for parallel exploration. `
2026-01-05 13:50:39 +09:00
}
2026-01-08 13:39:46 +09:00
const runInBackground = args . run_in_background === true
2026-01-05 13:50:39 +09:00
2026-01-08 13:03:28 +09:00
let skillContent : string | undefined
2026-01-05 13:50:39 +09:00
if ( args . skills && args . skills . length > 0 ) {
const { resolved , notFound } = resolveMultipleSkills ( args . skills )
if ( notFound . length > 0 ) {
const available = createBuiltinSkills ( ) . map ( s = > s . name ) . join ( ", " )
return ` ❌ Skills not found: ${ notFound . join ( ", " ) } . Available: ${ available } `
}
2026-01-08 13:03:28 +09:00
skillContent = Array . from ( resolved . values ( ) ) . join ( "\n\n" )
2026-01-05 13:50:39 +09:00
}
const messageDir = getMessageDir ( ctx . sessionID )
const prevMessage = messageDir ? findNearestMessageWithFields ( messageDir ) : null
const parentModel = prevMessage ? . model ? . providerID && prevMessage ? . model ? . modelID
? { providerID : prevMessage.model.providerID , modelID : prevMessage.model.modelID }
: undefined
// Handle resume case first
if ( args . resume ) {
2026-01-08 13:39:46 +09:00
if ( runInBackground ) {
try {
const task = await manager . resume ( {
sessionId : args.resume ,
prompt : args.prompt ,
parentSessionID : ctx.sessionID ,
parentMessageID : ctx.messageID ,
parentModel ,
} )
ctx . metadata ? . ( {
title : ` Resume: ${ task . description } ` ,
metadata : { sessionId : task.sessionID } ,
} )
return ` Background task resumed.
2026-01-05 13:50:39 +09:00
Task ID: ${ task . id }
Session ID: ${ task . sessionID }
Description: ${ task . description }
Agent: ${ task . agent }
Status: ${ task . status }
Agent continues with full previous context preserved.
Use \` background_output \` with task_id=" ${ task . id } " to check progress. `
2026-01-08 13:39:46 +09:00
} catch ( error ) {
const message = error instanceof Error ? error.message : String ( error )
return ` ❌ Failed to resume task: ${ message } `
}
2026-01-05 13:50:39 +09:00
}
2026-01-08 13:39:46 +09:00
const toastManager = getTaskToastManager ( )
const taskId = ` resume_sync_ ${ args . resume . slice ( 0 , 8 ) } `
const startTime = new Date ( )
if ( toastManager ) {
toastManager . addTask ( {
id : taskId ,
description : args.description ,
agent : "resume" ,
isBackground : false ,
} )
}
ctx . metadata ? . ( {
title : ` Resume: ${ args . description } ` ,
metadata : { sessionId : args.resume , sync : true } ,
} )
try {
await client . session . prompt ( {
path : { id : args.resume } ,
body : {
tools : {
task : false ,
sisyphus_task : false ,
} ,
parts : [ { type : "text" , text : args.prompt } ] ,
} ,
} )
} catch ( promptError ) {
if ( toastManager ) {
toastManager . removeTask ( taskId )
}
const errorMessage = promptError instanceof Error ? promptError.message : String ( promptError )
return ` ❌ Failed to send resume prompt: ${ errorMessage } \ n \ nSession ID: ${ args . resume } `
}
const messagesResult = await client . session . messages ( {
path : { id : args.resume } ,
} )
if ( messagesResult . error ) {
if ( toastManager ) {
toastManager . removeTask ( taskId )
}
return ` ❌ Error fetching result: ${ messagesResult . error } \ n \ nSession ID: ${ args . resume } `
}
const messages = ( ( messagesResult as { data? : unknown } ) . data ? ? messagesResult ) as Array < {
info ? : { role? : string ; time ? : { created? : number } }
parts? : Array < { type ? : string ; text? : string } >
} >
const assistantMessages = messages
. filter ( ( m ) = > m . info ? . role === "assistant" )
. sort ( ( a , b ) = > ( b . info ? . time ? . created ? ? 0 ) - ( a . info ? . time ? . created ? ? 0 ) )
const lastMessage = assistantMessages [ 0 ]
if ( toastManager ) {
toastManager . removeTask ( taskId )
}
if ( ! lastMessage ) {
return ` ❌ No assistant response found. \ n \ nSession ID: ${ args . resume } `
}
const textParts = lastMessage ? . parts ? . filter ( ( p ) = > p . type === "text" ) ? ? [ ]
const textContent = textParts . map ( ( p ) = > p . text ? ? "" ) . filter ( Boolean ) . join ( "\n" )
const duration = formatDuration ( startTime )
return ` Task resumed and completed in ${ duration } .
Session ID: ${ args . resume }
---
${ textContent || "(No text output)" } `
2026-01-05 13:50:39 +09:00
}
2026-01-06 15:16:57 +09:00
if ( args . category && args . subagent_type ) {
return ` ❌ Invalid arguments: Provide EITHER category OR subagent_type, not both. `
2026-01-05 13:50:39 +09:00
}
2026-01-06 15:16:57 +09:00
if ( ! args . category && ! args . subagent_type ) {
return ` ❌ Invalid arguments: Must provide either category or subagent_type. `
2026-01-05 13:50:39 +09:00
}
let agentToUse : string
let categoryModel : { providerID : string ; modelID : string } | undefined
2026-01-08 13:03:28 +09:00
let categoryPromptAppend : string | undefined
2026-01-05 13:50:39 +09:00
if ( args . category ) {
const resolved = resolveCategoryConfig ( args . category , userCategories )
if ( ! resolved ) {
return ` ❌ Unknown category: " ${ args . category } ". Available: ${ Object . keys ( { . . . DEFAULT_CATEGORIES , . . . userCategories } ).join(", ")} `
}
agentToUse = SISYPHUS_JUNIOR_AGENT
categoryModel = parseModelString ( resolved . config . model )
2026-01-08 13:03:28 +09:00
categoryPromptAppend = resolved . promptAppend || undefined
2026-01-05 13:50:39 +09:00
} else {
2026-01-06 15:16:57 +09:00
agentToUse = args . subagent_type ! . trim ( )
2026-01-05 13:50:39 +09:00
if ( ! agentToUse ) {
return ` ❌ Agent name cannot be empty. `
}
// Validate agent exists and is callable (not a primary agent)
try {
const agentsResult = await client . app . agents ( )
type AgentInfo = { name : string ; mode ? : "subagent" | "primary" | "all" }
const agents = ( agentsResult as { data? : AgentInfo [ ] } ) . data ? ? agentsResult as unknown as AgentInfo [ ]
const callableAgents = agents . filter ( ( a ) = > a . mode !== "primary" )
const callableNames = callableAgents . map ( ( a ) = > a . name )
if ( ! callableNames . includes ( agentToUse ) ) {
const isPrimaryAgent = agents . some ( ( a ) = > a . name === agentToUse && a . mode === "primary" )
if ( isPrimaryAgent ) {
return ` ❌ Cannot call primary agent " ${ agentToUse } " via sisyphus_task. Primary agents are top-level orchestrators. `
}
const availableAgents = callableNames
. sort ( )
. join ( ", " )
return ` ❌ Unknown agent: " ${ agentToUse } ". Available agents: ${ availableAgents } `
}
} catch {
// If we can't fetch agents, proceed anyway - the session.prompt will fail with a clearer error
}
}
2026-01-08 13:03:28 +09:00
const systemContent = buildSystemContent ( { skillContent , categoryPromptAppend } )
2026-01-05 13:50:39 +09:00
if ( runInBackground ) {
try {
const task = await manager . launch ( {
description : args.description ,
prompt : args.prompt ,
agent : agentToUse ,
parentSessionID : ctx.sessionID ,
parentMessageID : ctx.messageID ,
parentModel ,
model : categoryModel ,
2026-01-08 11:26:27 +09:00
skills : args.skills ,
2026-01-08 13:03:28 +09:00
skillContent : systemContent ,
2026-01-05 13:50:39 +09:00
} )
ctx . metadata ? . ( {
title : args.description ,
metadata : { sessionId : task.sessionID , category : args.category } ,
} )
return ` Background task launched.
Task ID: ${ task . id }
Session ID: ${ task . sessionID }
Description: ${ task . description }
Agent: ${ task . agent } ${ args . category ? ` (category: ${ args . category } ) ` : "" }
Status: ${ task . status }
System notifies on completion. Use \` background_output \` with task_id=" ${ task . id } " to check. `
} catch ( error ) {
const message = error instanceof Error ? error.message : String ( error )
return ` ❌ Failed to launch task: ${ message } `
}
}
2026-01-06 13:40:27 +09:00
const toastManager = getTaskToastManager ( )
let taskId : string | undefined
2026-01-06 11:46:27 +09:00
2026-01-06 13:40:27 +09:00
try {
2026-01-05 13:50:39 +09:00
const createResult = await client . session . create ( {
body : {
parentID : ctx.sessionID ,
title : ` Task: ${ args . description } ` ,
} ,
} )
if ( createResult . error ) {
return ` ❌ Failed to create session: ${ createResult . error } `
}
const sessionID = createResult . data . id
2026-01-06 13:40:27 +09:00
taskId = ` sync_ ${ sessionID . slice ( 0 , 8 ) } `
2026-01-05 13:50:39 +09:00
const startTime = new Date ( )
2026-01-06 13:40:27 +09:00
if ( toastManager ) {
toastManager . addTask ( {
id : taskId ,
description : args.description ,
agent : agentToUse ,
isBackground : false ,
2026-01-08 11:26:27 +09:00
skills : args.skills ,
2026-01-06 13:40:27 +09:00
} )
}
2026-01-05 13:50:39 +09:00
ctx . metadata ? . ( {
title : args.description ,
metadata : { sessionId : sessionID , category : args.category , sync : true } ,
} )
2026-01-07 23:45:04 +09:00
try {
await client . session . prompt ( {
path : { id : sessionID } ,
body : {
agent : agentToUse ,
model : categoryModel ,
2026-01-08 13:03:28 +09:00
system : systemContent ,
2026-01-07 23:45:04 +09:00
tools : {
task : false ,
sisyphus_task : false ,
} ,
parts : [ { type : "text" , text : args.prompt } ] ,
2026-01-05 13:50:39 +09:00
} ,
2026-01-07 23:45:04 +09:00
} )
} catch ( promptError ) {
if ( toastManager && taskId !== undefined ) {
toastManager . removeTask ( taskId )
}
const errorMessage = promptError instanceof Error ? promptError.message : String ( promptError )
if ( errorMessage . includes ( "agent.name" ) || errorMessage . includes ( "undefined" ) ) {
return ` ❌ Agent " ${ agentToUse } " not found. Make sure the agent is registered in your opencode.json or provided by a plugin. \ n \ nSession ID: ${ sessionID } `
}
return ` ❌ Failed to send prompt: ${ errorMessage } \ n \ nSession ID: ${ sessionID } `
}
2026-01-05 13:50:39 +09:00
const messagesResult = await client . session . messages ( {
path : { id : sessionID } ,
} )
if ( messagesResult . error ) {
2026-01-07 23:45:04 +09:00
return ` ❌ Error fetching result: ${ messagesResult . error } \ n \ nSession ID: ${ sessionID } `
2026-01-05 13:50:39 +09:00
}
const messages = ( ( messagesResult as { data? : unknown } ) . data ? ? messagesResult ) as Array < {
2026-01-07 23:45:04 +09:00
info ? : { role? : string ; time ? : { created? : number } }
2026-01-05 13:50:39 +09:00
parts? : Array < { type ? : string ; text? : string } >
} >
2026-01-07 23:45:04 +09:00
const assistantMessages = messages
. filter ( ( m ) = > m . info ? . role === "assistant" )
. sort ( ( a , b ) = > ( b . info ? . time ? . created ? ? 0 ) - ( a . info ? . time ? . created ? ? 0 ) )
const lastMessage = assistantMessages [ 0 ]
if ( ! lastMessage ) {
return ` ❌ No assistant response found. \ n \ nSession ID: ${ sessionID } `
}
2026-01-05 13:50:39 +09:00
const textParts = lastMessage ? . parts ? . filter ( ( p ) = > p . type === "text" ) ? ? [ ]
const textContent = textParts . map ( ( p ) = > p . text ? ? "" ) . filter ( Boolean ) . join ( "\n" )
const duration = formatDuration ( startTime )
2026-01-06 13:40:27 +09:00
if ( toastManager ) {
toastManager . removeTask ( taskId )
}
2026-01-05 13:50:39 +09:00
return ` Task completed in ${ duration } .
Agent: ${ agentToUse } ${ args . category ? ` (category: ${ args . category } ) ` : "" }
Session ID: ${ sessionID }
---
${ textContent || "(No text output)" } `
} catch ( error ) {
2026-01-06 13:40:27 +09:00
if ( toastManager && taskId !== undefined ) {
toastManager . removeTask ( taskId )
}
2026-01-05 13:50:39 +09:00
const message = error instanceof Error ? error.message : String ( error )
return ` ❌ Task failed: ${ message } `
}
} ,
} )
}