opencode-plugin.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227
  1. import type { Plugin } from '@opencode-ai/plugin'
  2. import { tool } from '@opencode-ai/plugin'
  3. import type { Ability, LoadedAbility, ExecutorContext } from './types/index.js'
  4. import { loadAbilities } from './loader/index.js'
  5. import { validateAbility, validateInputs } from './validator/index.js'
  6. import { formatExecutionResult } from './executor/index.js'
  7. import { ExecutionManager } from './executor/execution-manager.js'
  8. /**
  9. * Minimal Abilities Plugin
  10. *
  11. * Stripped to essentials:
  12. * - Load abilities from YAML
  13. * - Execute script steps sequentially
  14. * - Block tools during execution (enforcement)
  15. * - Inject context into chat messages
  16. *
  17. * NO: sessions, agents, triggers, toasts, cleanup timers
  18. */
  19. // Tools that are ALWAYS allowed (read-only)
  20. const ALWAYS_ALLOWED_TOOLS = [
  21. 'ability.list',
  22. 'ability.status',
  23. 'ability.cancel',
  24. 'read',
  25. 'glob',
  26. 'grep',
  27. ]
  28. export const AbilitiesPlugin: Plugin = async (ctx) => {
  29. const abilities = new Map<string, LoadedAbility>()
  30. const executionManager = new ExecutionManager()
  31. const abilitiesDir = `${ctx.directory}/.opencode/abilities`
  32. // Load abilities on startup
  33. try {
  34. const loaded = await loadAbilities({ projectDir: abilitiesDir, includeGlobal: false })
  35. for (const [name, ability] of loaded) {
  36. abilities.set(name, ability)
  37. }
  38. console.log(`[abilities] Loaded ${abilities.size} abilities from ${abilitiesDir}`)
  39. } catch (err) {
  40. console.log(`[abilities] Could not load abilities:`, err instanceof Error ? err.message : err)
  41. }
  42. const createExecutorContext = (): ExecutorContext => {
  43. return {
  44. cwd: ctx.directory,
  45. env: {},
  46. }
  47. }
  48. const buildAbilityContextInjection = (execution: any): string => {
  49. const ability = execution.ability
  50. const currentStep = execution.currentStep
  51. const completed = execution.completedSteps.length
  52. const total = ability.steps.length
  53. const lines = [
  54. `## 🔄 Active Ability: ${ability.name}`,
  55. '',
  56. `**Progress:** ${completed}/${total} steps completed`,
  57. '',
  58. ]
  59. if (currentStep) {
  60. lines.push(`### Current Step: ${currentStep.id}`)
  61. if (currentStep.description) lines.push(currentStep.description)
  62. lines.push('')
  63. lines.push(`**Action:** Script is executing. Wait for completion.`)
  64. lines.push('')
  65. lines.push('⚠️ **ENFORCEMENT ACTIVE** - Other tools are blocked until step completes.')
  66. }
  67. return lines.join('\n')
  68. }
  69. return {
  70. // Hook: Inject ability context into every chat message
  71. async 'chat.message'(_input, output) {
  72. try {
  73. const activeExecution = executionManager.getActive()
  74. if (activeExecution && activeExecution.status === 'running') {
  75. output.parts.unshift({
  76. type: 'text',
  77. text: buildAbilityContextInjection(activeExecution),
  78. } as any)
  79. }
  80. } catch (err) {
  81. console.error('[abilities] chat.message error:', err)
  82. }
  83. },
  84. // Hook: Block unauthorized tools during ability execution
  85. async 'tool.execute.before'(input, _output) {
  86. try {
  87. const execution = executionManager.getActive()
  88. if (!execution) return // No ability running, allow all tools
  89. const currentStep = execution.currentStep
  90. if (!currentStep) return
  91. // Always allow read-only tools
  92. if (ALWAYS_ALLOWED_TOOLS.includes(input.tool)) return
  93. // Script steps block ALL other tools (deterministic execution)
  94. if (currentStep.type === 'script') {
  95. throw new Error(
  96. `[abilities] Tool '${input.tool}' blocked during script step '${currentStep.id}'. ` +
  97. `Script steps run deterministically - wait for completion.`
  98. )
  99. }
  100. } catch (err) {
  101. if (err instanceof Error && err.message.startsWith('[abilities]')) {
  102. throw err
  103. }
  104. console.error('[abilities] tool.execute.before error:', err)
  105. }
  106. },
  107. // Hook: Cleanup on session deletion
  108. async event({ event }) {
  109. try {
  110. if (event.type === 'session.deleted') {
  111. executionManager.cleanup()
  112. }
  113. } catch (err) {
  114. console.error('[abilities] event handler error:', err)
  115. }
  116. },
  117. tool: {
  118. 'ability.list': tool({
  119. description: 'List all available abilities',
  120. args: {},
  121. async execute() {
  122. if (abilities.size === 0) return 'No abilities loaded.'
  123. const list = Array.from(abilities.values()).map(loaded => {
  124. const stepCount = loaded.ability.steps.length
  125. return `- **${loaded.ability.name}**: ${loaded.ability.description} (${stepCount} steps)`
  126. })
  127. return list.join('\n')
  128. },
  129. }),
  130. 'ability.run': tool({
  131. description: `Execute an ability workflow. Available: ${Array.from(abilities.keys()).join(', ') || 'none loaded'}`,
  132. args: {
  133. name: tool.schema.string().describe('Ability name to run'),
  134. inputs: tool.schema.optional(tool.schema.any()).describe('Input values for the ability'),
  135. },
  136. async execute({ name, inputs = {} }) {
  137. const loaded = abilities.get(name)
  138. if (!loaded) {
  139. return JSON.stringify({ error: `Ability '${name}' not found` })
  140. }
  141. const ability = loaded.ability
  142. // Validate inputs
  143. const inputErrors = validateInputs(ability, inputs as Record<string, unknown>)
  144. if (inputErrors.length > 0) {
  145. return JSON.stringify({
  146. error: 'Input validation failed',
  147. details: inputErrors.map(e => e.message)
  148. })
  149. }
  150. try {
  151. const execution = await executionManager.execute(
  152. ability,
  153. inputs as Record<string, unknown>,
  154. createExecutorContext()
  155. )
  156. return JSON.stringify({
  157. status: execution.status,
  158. ability: ability.name,
  159. result: formatExecutionResult(execution),
  160. })
  161. } catch (error) {
  162. return JSON.stringify({
  163. status: 'error',
  164. error: error instanceof Error ? error.message : String(error)
  165. })
  166. }
  167. },
  168. }),
  169. 'ability.status': tool({
  170. description: 'Get status of active ability execution',
  171. args: {},
  172. async execute() {
  173. const execution = executionManager.getActive()
  174. if (!execution) {
  175. return JSON.stringify({ status: 'none', message: 'No active ability' })
  176. }
  177. return JSON.stringify({
  178. status: execution.status,
  179. ability: execution.ability.name,
  180. currentStep: execution.currentStep?.id,
  181. progress: `${execution.completedSteps.length}/${execution.ability.steps.length}`,
  182. })
  183. },
  184. }),
  185. 'ability.cancel': tool({
  186. description: 'Cancel the active ability execution',
  187. args: {},
  188. async execute() {
  189. const cancelled = executionManager.cancel()
  190. return JSON.stringify(cancelled
  191. ? { status: 'cancelled', message: 'Ability cancelled' }
  192. : { status: 'none', message: 'No active ability' })
  193. },
  194. }),
  195. },
  196. }
  197. }
  198. export default AbilitiesPlugin