11import type { Plugin } from "@opencode-ai/plugin"
22
33export const ChainloopTrace : Plugin = async ( { $ } ) => {
4- // The commit-msg hook links sessions to commits by cross-referencing
5- // staged files against AI line attributions recorded by post-tool-use.
6- // If no file-writing tools (edit, write, apply_patch) are invoked during
7- // the session, there will be no attributions and the commit will not be
8- // marked as AI-assisted.
94 const fileWritingTools = [ "edit" , "write" , "apply_patch" ]
5+ const commandTools = [ "bash" ]
106
11- function filePathFromArgs ( args : any ) : string {
12- if ( args ?. filePath ) return args . filePath
13- if ( args ?. path ) return args . path
14- return ""
7+ function filePathsFromArgs ( args : any ) : string [ ] {
8+ if ( args ?. filePath ) return [ args . filePath ]
9+ if ( args ?. path ) return [ args . path ]
10+ if ( args ?. patchText ) return parsePatchPaths ( args . patchText )
11+ return [ ]
1512 }
1613
14+ // parsePatchPaths extracts affected file paths from an apply_patch
15+ // patchText payload. Each section starts with *** Add File:, *** Update
16+ // File:, or *** Delete File: followed by the path. Paths are deduplicated
17+ // while preserving first-seen order.
18+ function parsePatchPaths ( patchText : string ) : string [ ] {
19+ const paths : string [ ] = [ ]
20+ const seen = new Set < string > ( )
21+ const re = / ^ \* \* \* (?: A d d | U p d a t e | D e l e t e ) F i l e : ( .+ ) $ / gm
22+ let m
23+ while ( ( m = re . exec ( patchText ) ) !== null ) {
24+ const p = m [ 1 ] . trim ( )
25+ if ( p && ! seen . has ( p ) ) {
26+ seen . add ( p )
27+ paths . push ( p )
28+ }
29+ }
30+ return paths
31+ }
32+
33+ // fire-and-forget: tracing must never block tool execution. If chainloop
34+ // is unavailable or errors, log to stderr and move on.
1735 async function fire ( event : string , payload : Record < string , any > ) {
1836 const json = JSON . stringify ( payload )
19- await $ `echo ${ json } | chainloop trace hook opencode ${ event } `
37+ try {
38+ await $ `echo ${ json } | chainloop trace hook opencode ${ event } `
39+ } catch ( err ) {
40+ console . error ( `chainloop-trace: ${ event } hook failed: ${ err } ` )
41+ }
2042 }
2143
2244 return {
@@ -31,22 +53,42 @@ export const ChainloopTrace: Plugin = async ({ $ }) => {
3153 }
3254 } ,
3355 "tool.execute.before" : async ( input , output ) => {
56+ if ( commandTools . includes ( input . tool ) ) {
57+ await fire ( "pre-tool-use" , {
58+ session_id : input . sessionID ,
59+ hook_event_name : "tool.execute.before" ,
60+ tool_name : input . tool ,
61+ } )
62+ return
63+ }
3464 if ( ! fileWritingTools . includes ( input . tool ) ) return
35- await fire ( "pre-tool-use" , {
36- session_id : input . sessionID ,
37- hook_event_name : "tool.execute.before" ,
38- tool_name : input . tool ,
39- file_path : filePathFromArgs ( output . args ) ,
40- } )
65+ for ( const fp of filePathsFromArgs ( output . args ) ) {
66+ await fire ( "pre-tool-use" , {
67+ session_id : input . sessionID ,
68+ hook_event_name : "tool.execute.before" ,
69+ tool_name : input . tool ,
70+ file_path : fp ,
71+ } )
72+ }
4173 } ,
4274 "tool.execute.after" : async ( input ) => {
75+ if ( commandTools . includes ( input . tool ) ) {
76+ await fire ( "post-tool-use" , {
77+ session_id : input . sessionID ,
78+ hook_event_name : "tool.execute.after" ,
79+ tool_name : input . tool ,
80+ } )
81+ return
82+ }
4383 if ( ! fileWritingTools . includes ( input . tool ) ) return
44- await fire ( "post-tool-use" , {
45- session_id : input . sessionID ,
46- hook_event_name : "tool.execute.after" ,
47- tool_name : input . tool ,
48- file_path : filePathFromArgs ( input . args ) ,
49- } )
84+ for ( const fp of filePathsFromArgs ( input . args ) ) {
85+ await fire ( "post-tool-use" , {
86+ session_id : input . sessionID ,
87+ hook_event_name : "tool.execute.after" ,
88+ tool_name : input . tool ,
89+ file_path : fp ,
90+ } )
91+ }
5092 } ,
5193 }
5294}
0 commit comments